﻿using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Xml;
using System.Xml.Linq;
using AzureStorageMapper.Criterias.TableStorage;
using AzureStorageMapper.TableStorage.ActionModes;
using AzureStorageMapper.TableStorage.Attributes;
using AzureStorageMapper.TableStorage.Utils;

namespace AzureStorageMapper.TableStorage.RESTCommunicate
{
    public class TalkToTableStorage
    {
        #region Consts
        private const string TableStorageRequestContentType = "application/atom+xml";
        private const string UriFormat = "http://{0}.table.core.windows.net/{1}";
        private const string DataServiceVersionHeader = "DataServiceVersion";
        private const string DataServiceVersionValueVer1 = "1.0;NetFx";
        private const string DataServiceVersionValueVer2 = "2.0;NetFx";
        private const string MaxDataServiceVersionHeader = "MaxDataServiceVersion";
        private const string MaxDataServiceVersionValueVer1 = "1.0;NetFx";
        private const string MaxDataServiceVersionValueVer2 = "2.0;NetFx";

        private const string CreateTableBodyFormat = "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>"
                                                      + "<entry xmlns:d=\"http://schemas.microsoft.com/ado/2007/08/dataservices\" "
                                                      + "       xmlns:m=\"http://schemas.microsoft.com/ado/2007/08/dataservices/metadata\" "
                                                      + "       xmlns=\"http://www.w3.org/2005/Atom\">"
                                                      + "   <title />"
                                                      + "   <updated>{0}</updated>"
                                                      + "   <author><name/></author><id/>"
                                                      + "   <content type=\"application/xml\">"
                                                      + "      <m:properties><d:TableName>{1}</d:TableName></m:properties>"
                                                      + "   </content>"
                                                      + "</entry>";
        private const string InsertEntityFormat = "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>"
                                                + "<entry xmlns:d=\"http://schemas.microsoft.com/ado/2007/08/dataservices\" "
                                                + "       xmlns:m=\"http://schemas.microsoft.com/ado/2007/08/dataservices/metadata\" "
                                                + "       xmlns=\"http://www.w3.org/2005/Atom\"> "
                                                + "     <title /> "
                                                + "     <updated>{0}</updated> "
                                                + "     <author><name /></author> "
                                                + "     <id /> "
                                                + "     <content type=\"application/xml\"> "
                                                + "         <m:properties> "
                                                + "{1}"
                                                + "         </m:properties> "
                                                + "     </content> "
                                                + "</entry>";

        #endregion

        private static XNamespace def = @"http://www.w3.org/2005/Atom";
        private static XNamespace d = @"http://schemas.microsoft.com/ado/2007/08/dataservices";
        private static XNamespace m = @"http://schemas.microsoft.com/ado/2007/08/dataservices/metadata";

        private string _accountName;
        private string _accountKey;

        public TalkToTableStorage(string accountName, string accountKey, string endpoint = null, bool isHttps = false)
        {
            this._accountName = accountName;
            this._accountKey = accountKey;
        }

        public void CreateTable(TableInfo tInfo)
        {
            DateTime current = DateTime.UtcNow;
            string body = string.Format(CreateTableBodyFormat, current.ToString("o"), tInfo.Name);

            IDictionary<string, string> extraHeaders = new Dictionary<string, string>();
            extraHeaders.Add(DataServiceVersionHeader, DataServiceVersionValueVer1);
            extraHeaders.Add(MaxDataServiceVersionHeader, MaxDataServiceVersionValueVer1);

            HttpWebRequest request = CreateRequest("POST", "Tables", body, extraHeaders);
                        
            try
            {
                using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.Created)
                    {
                        // 201
                        Trace.TraceInformation("Table '{0}' created", tInfo.Name);
                    }
                    else
                        Trace.TraceWarning("Unknow status: ({0}) -- '{1}'", response.StatusCode, response.StatusDescription);
                }
            }
            catch (WebException ex)
            {
                HttpWebResponse exRep = ex.Response as HttpWebResponse;
                if (exRep.StatusCode == HttpStatusCode.Conflict)
                {
                    // 409
                    Trace.TraceWarning("Table '{0}' existed", tInfo.Name);
                }
                else
                    throw ex;
            }
            finally
            {
                if (request != null)
                {
                    request.Abort();
                }
            }
        }

        public void DeleteTable(TableInfo tInfo)
        {
            IDictionary<string, string> extraHeaders = new Dictionary<string, string>();
            extraHeaders.Add(DataServiceVersionHeader, DataServiceVersionValueVer1);
            extraHeaders.Add(MaxDataServiceVersionHeader, MaxDataServiceVersionValueVer1);

            HttpWebRequest request = CreateRequest("DELETE", string.Format("Tables('{0}')", tInfo.Name), null, extraHeaders);

            try
            {
                using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.NoContent)
                    {
                        // 204
                        Trace.TraceInformation("Table '{0}' deleted", tInfo.Name);
                    }
                    else if (response.StatusCode == HttpStatusCode.NotFound)
                        Trace.TraceInformation("Table '{0}' not found", tInfo.Name);
                    else
                        Trace.TraceError("Failed to delete table '{0}'. ({1}) '{2}'", tInfo.Name, response.StatusCode, response.StatusDescription);
                }
            }
            catch
            {
                throw;
            }
            finally
            {
                if (request != null)
                {
                    request.Abort();
                }
            }
            /*
            // not sure why this doesn`t work
            request.BeginGetResponse(ar =>
            {
                HttpWebRequest req2 = (HttpWebRequest)ar.AsyncState;
                var response = (HttpWebResponse)req2.EndGetResponse(ar);

                if (response.StatusCode == HttpStatusCode.NoContent)
                {
                    // 204
                    Trace.TraceInformation("Table '{0}' deleted", tInfo.Name);
                }
                else if (response.StatusCode == HttpStatusCode.NotFound)
                    Trace.TraceInformation("Table '{0}' not found", tInfo.Name);
                else
                    Trace.TraceError("Failed to delete table '{0}'. ({1}) '{2}'", tInfo.Name, response.StatusCode, response.StatusDescription);
            
            }, request);
            */
        }

        public void InsertEntity(EntityInfo eInfo, SaveMode mode)
        {
            string body = ConstructRequestBodyXml(eInfo);
            IDictionary<string, string> extraHeaders = new Dictionary<string, string>();
            extraHeaders.Add(DataServiceVersionHeader, DataServiceVersionValueVer1);
            extraHeaders.Add(MaxDataServiceVersionHeader, MaxDataServiceVersionValueVer1);

            string httpMethod = ActionModeToHttpMethod.Convert(mode);
            string resource;
            switch (mode)
            {
                case SaveMode.Insert:
                    resource = eInfo.TableName;
                    break;
                case SaveMode.InsertOrReplace:
                case SaveMode.InsertOrMerge:
                    resource = string.Format("{0}(PartitionKey='{1}',RowKey='{2}')", eInfo.TableName, eInfo.PartitionKey, eInfo.RowKey);
                    break;
                default:
                    throw new NotSupportedException(string.Format("Invalid SaveMode: '{0}'", mode));
            }
            HttpWebRequest request = CreateRequest(httpMethod, resource, body, extraHeaders);

            try
            {
                using (var response = request.GetResponse() as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.Created)
                    {
                        // 201
                        Trace.TraceInformation("Entity '{0}' inserted", eInfo.TableName);
                    }
                    else
                        Trace.TraceWarning("Unknow status: ({0}) -- '{1}'", response.StatusCode, response.StatusDescription);
                }
            }
            catch
            {
                throw;
            }
            finally
            {
                if (request != null)
                {
                    request.Abort();
                }
            }
        }

        public void UpdateEntity(EntityInfo eInfo, UpdateMode mode)
        {
            string body = ConstructRequestBodyXml(eInfo);
            IDictionary<string, string> extraHeaders = new Dictionary<string, string>();
            extraHeaders.Add(DataServiceVersionHeader, DataServiceVersionValueVer1);
            extraHeaders.Add(MaxDataServiceVersionHeader, MaxDataServiceVersionValueVer2);
            extraHeaders.Add("If-Match", "*");

            string httpMethod = ActionModeToHttpMethod.Convert(mode);

            HttpWebRequest request = CreateRequest(httpMethod,
                                                   string.Format("{0}(PartitionKey='{1}',RowKey='{2}')", eInfo.TableName, eInfo.PartitionKey, eInfo.RowKey),
                                                   body, extraHeaders);
            try
            {
                using (var response = request.GetResponse() as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.NoContent)
                    {
                        // 204
                        Trace.TraceInformation("Entity '{0}' updated", eInfo.TableName);
                    }
                    else
                        Trace.TraceWarning("Unknow status: ({0}) -- '{1}'", response.StatusCode, response.StatusDescription);
                }
            }
            catch
            {
                throw;
            }
            finally
            {
                if (request != null)
                {
                    request.Abort();
                }
            }
        }

        public void DeleteEntity(string tableName, string partitionKey, string rowKey)
        {
            string entryInfo = string.Format("{0}(PartitionKey='{1}', RowKey='{2}')", tableName, partitionKey, rowKey);
            IDictionary<string, string> extraHeaders = new Dictionary<string, string>();
            extraHeaders.Add(DataServiceVersionHeader, DataServiceVersionValueVer1);
            extraHeaders.Add(MaxDataServiceVersionHeader, MaxDataServiceVersionValueVer1);
            extraHeaders.Add("If-Match", "*");
            HttpWebRequest request = CreateRequest("DELETE", entryInfo, null, extraHeaders);

            try
            {
                using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.NoContent)
                    {
                        // 204
                        Trace.TraceInformation("Entry '{0}' deleted", entryInfo);
                    }
                    else
                        Trace.TraceError("Failed to delete entry '{0}'. ({1}) '{2}'", entryInfo, response.StatusCode, response.StatusDescription);
                }
            }
            catch
            {
                throw;
            }
            finally
            {
                if (request != null)
                {
                    request.Abort();
                }
            }

        }

        public T QueryEntity<T>(string tableName, string partitionKey, string rowKey) where T : TableEntity
        {
            List<string> fields = new List<string>();
            TableInfo tInfo = TableUtilities.GetTableInfo(typeof(T));
            foreach (var item in tInfo.Columns)
            {
                fields.Add(item.Name);
            }

            string resource = null;

            if (fields.Count > 0)
            {
                resource = string.Format("{0}(PartitionKey='{1}',RowKey='{2}')?$select={3}",
                                         tableName, partitionKey, rowKey, string.Join(",", fields));
            }
            else
            {
                resource = string.Format("{0}(PartitionKey='{1}',RowKey='{2}')", tableName, partitionKey, rowKey);
            }

            IDictionary<string, string> extraHeaders = new Dictionary<string, string>();
            extraHeaders.Add(DataServiceVersionHeader, DataServiceVersionValueVer2);
            extraHeaders.Add(MaxDataServiceVersionHeader, MaxDataServiceVersionValueVer2);

            HttpWebRequest request = CreateRequest("GET", resource, null, extraHeaders);

            ConstructorInfo ci = typeof(T).GetConstructor(new Type[] { typeof(string), typeof(string) });
            T entry = (T)ci.Invoke(new object[] { partitionKey, rowKey });

            try
            {
                using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.OK)
                    {
                        using (Stream stream = response.GetResponseStream())
                        {
                            XDocument xdoc = XDocument.Load(stream);

                            foreach (var ti in tInfo.Columns)
                            {
                                XElement el = xdoc.Root.Element(def + "content")
                                                       .Element(m + "properties")
                                                       .Element(d + ti.Name);

                                XAttribute nullAttr = el.Attribute(m + "null");

                                PropertyInfo[] pInfo = typeof(T).GetProperties();
                                foreach (var pi in pInfo)
                                {
                                    object columnName;
                                    TableColumn tc = TableUtilities.GetColumnInfo(pi, out columnName);
                                    if (pi.Name.Equals(ti.Name, StringComparison.InvariantCultureIgnoreCase)
                                        || ti.Name.Equals(tc.Name, StringComparison.InvariantCultureIgnoreCase))
                                    {
                                        try
                                        {
                                            object propVal = null;
                                            if (nullAttr == null)
                                                propVal = GetPropertyValue(el.Value, ti.ColumnType);

                                            pi.SetValue(entry, propVal, null);
                                            break;
                                        }
                                        catch (FormatException fe)
                                        {
                                            Trace.TraceError("Failed to convert value for '{0}',  raw value : '{1}'\n{2}", ti.Name, el.Value, fe.StackTrace);
                                        }
                                    }
                                }

                            }
                        }

                        //PropertyInfo propPartKey = typeof(T).GetProperty("PartitionKey");
                        //propPartKey.SetValue(entry, partitionKey, null);

                        //PropertyInfo propRowKey = typeof(T).GetProperty("RowKey");
                        //propRowKey.SetValue(entry, rowKey, null);
                    }
                }
            }
            catch (WebException ex)
            {
                bool throwEx = true;
                using (HttpWebResponse response = ex.Response as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.NotFound)
                    {
                        throwEx = false;
                        entry = null;
                    }
                }

                if (throwEx)
                {
                    throw;
                }
            }
            finally
            {
                if (request != null)
                {
                    request.Abort();
                }
            }
            return entry;
        }

        public QueryEntitiesResult<T> QueryEntities<T>(string tableName, string continuationQuery = null, string filterStr = null, int? topCount = null) where T : TableEntity
        {
            StringBuilder resource = new StringBuilder();
            resource.AppendFormat("{0}()?", tableName);

            if (topCount.HasValue)
            {
                resource.AppendFormat("&$top={0}", topCount.Value);
            }

            if (continuationQuery != null)
            {
                resource.AppendFormat("&{0}", continuationQuery);
            }

            if (!string.IsNullOrWhiteSpace(filterStr))
            {
                resource.AppendFormat("&$filter={0}", HttpUtility.UrlPathEncode(filterStr));
            }

            List<string> modelFields = new List<string>()
                {
                    "PartitionKey",
                    "RowKey"
                };
            TableInfo tInfo2 = TableUtilities.GetTableInfo(typeof(T));
            foreach (var item in tInfo2.Columns)
            {
                modelFields.Add(item.Name);
            }

            if (modelFields.Count > 0)
            {
                resource.AppendFormat("&$select={0}", HttpUtility.UrlPathEncode(string.Join(",", modelFields)));
            }

            IDictionary<string, string> extraHeaders = new Dictionary<string, string>();
            extraHeaders.Add(DataServiceVersionHeader, DataServiceVersionValueVer2);
            extraHeaders.Add(MaxDataServiceVersionHeader, MaxDataServiceVersionValueVer2);

            HttpWebRequest request = CreateRequest("GET", resource.ToString(), null, extraHeaders);
            ContinuationQuery cQuery;
            List<T> resultList = HandleGetEntitiesRequest<T>(request, modelFields, out cQuery);

            QueryEntitiesResult<T> result = new QueryEntitiesResult<T>();
            result.Result = resultList;
            result.Continuation = cQuery;

            return result;
        }
        
        #region Private Methods
        private List<T> HandleGetEntitiesRequest<T>(HttpWebRequest request, List<string> modelFields, out ContinuationQuery cQuery)
        {
            List<T> results = new List<T>();
            try
            {
                using (HttpWebResponse response = request.GetResponse() as HttpWebResponse)
                {
                    if (response.StatusCode == HttpStatusCode.OK)
                    {
                        using (Stream stream = response.GetResponseStream())
                        {
                            XDocument xdoc = XDocument.Load(stream);
                            var entries = xdoc.Root.Elements(def + "entry");

                            List<string> fields = new List<string>();
                            TableInfo tInfo = TableUtilities.GetTableInfo(typeof(T));
                            foreach (var item in tInfo.Columns)
                            {
                                fields.Add(item.Name);
                            }

                            foreach (var item in entries)
                            {
                                ConstructorInfo ci = typeof(T).GetConstructor(new Type[] { typeof(string), typeof(string) });
                                T entry = (T)ci.Invoke(new object[] { "", "" });

                                foreach (var ti in tInfo.Columns)
                                {
                                    XElement el = item.Element(def + "content")
                                                       .Element(m + "properties")
                                                       .Element(d + ti.Name);

                                    XAttribute nullAttr = el.Attribute(m + "null");

                                    PropertyInfo[] pInfo = typeof(T).GetProperties();
                                    foreach (var pi in pInfo)
                                    {
                                        object columnName;
                                        TableColumn tc = TableUtilities.GetColumnInfo(pi, out columnName);
                                        if (pi.Name.Equals(ti.Name, StringComparison.InvariantCultureIgnoreCase)
                                            || ti.Name.Equals(tc.Name, StringComparison.InvariantCultureIgnoreCase))
                                        {
                                            try
                                            {
                                                object propVal = null;
                                                if (nullAttr == null)
                                                    propVal = GetPropertyValue(el.Value, ti.ColumnType);

                                                pi.SetValue(entry, propVal, null);
                                                break;
                                            }
                                            catch (FormatException fe)
                                            {
                                                Trace.TraceError("Failed to convert value for '{0}',  raw value : '{1}'\n{2}", ti.Name, el.Value, fe.StackTrace);
                                                throw fe;
                                            }
                                        }
                                    }
                                }

                                XElement pel = item.Element(def + "content")
                                                       .Element(m + "properties")
                                                       .Element(d + "PartitionKey");
                                PropertyInfo propPartKey = typeof(T).GetProperty("PartitionKey");
                                propPartKey.SetValue(entry, pel.Value, null);

                                XElement rel = item.Element(def + "content")
                                                       .Element(m + "properties")
                                                       .Element(d + "RowKey");
                                PropertyInfo propRowKey = typeof(T).GetProperty("RowKey");
                                propRowKey.SetValue(entry, rel.Value, null);

                                results.Add(entry);
                            }
                        }
                    }

                    cQuery = new ContinuationQuery();
                    cQuery.NextTableName = response.Headers["x-ms-continuation-NextTableName"];
                    cQuery.NextPartitionKey = response.Headers["x-ms-continuation-NextPartitionKey"];
                    cQuery.NextRowKey = response.Headers["x-ms-continuation-NextRowKey"];
                }
            }
            catch
            {
                throw;
            }
            finally
            {
                if (request != null)
                {
                    request.Abort();
                }
            }

            return results;
        }

        private string ConstructRequestBodyXml(EntityInfo eInfo)
        {
            DateTime current = DateTime.UtcNow;

            StringBuilder entryStr = new StringBuilder();
            foreach (var item in eInfo.Columns)
            {
                if (item.Value == null)
                {
                    entryStr.AppendFormat("<d:{0} m:null=\"true\" />", item.Key.Name);
                }
                else
                {
                    string serializedValue = GetPropertySerializedValue(item.Value, item.Key.ColumnType);
                    entryStr.AppendFormat("<d:{0}>{1}</d:{0}>", item.Key.Name, serializedValue);
                }
            }
            entryStr.AppendFormat("<d:PartitionKey>{0}</d:PartitionKey>", eInfo.PartitionKey);
            entryStr.AppendFormat("<d:RowKey>{0}</d:RowKey>", eInfo.RowKey);

            return string.Format(InsertEntityFormat, current.ToString("o"), entryStr.ToString());
        }

        private object GetPropertyValue(string strVal, Type type)
        {
            if (type == typeof(string))
                return strVal;
            if (type == typeof(DateTime))
                return XmlConvert.ToDateTime(strVal, XmlDateTimeSerializationMode.RoundtripKind);
            if (type == typeof(byte[]))
                return UTF8Encoding.UTF8.GetBytes(strVal);
            else
            {
                MethodInfo praseMethod = type.GetMethod("Parse", new Type[] { typeof(string) });
                return praseMethod.Invoke(type, new object[] { strVal });
            }
        }

        private string GetPropertySerializedValue(object value, Type type)
        {
            if (value == null)
                return null;
            if (type == typeof(DateTime))
                return XmlConvert.ToString((DateTime)value, XmlDateTimeSerializationMode.RoundtripKind);
            else if (type == typeof(byte[]))
                return UTF8Encoding.UTF8.GetString((byte[])value);
            else if (type == typeof(bool) || type == typeof(double) || type == typeof(float)
                    || type == typeof(Guid) || type == typeof(int) || type == typeof(long) || type == typeof(string))
                return value.ToString();
            else
                throw new NotSupportedException(string.Format("Invalid type: '{0}' with value : '{1}'", type, value));
        }

        private HttpWebRequest CreateRequest(string httpMethod, string resource,
                                             string requestBody = null,
                                             IDictionary<string, string> extraHeaders = null)
        {
            DateTime current = DateTime.UtcNow;

            HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(string.Format(UriFormat, _accountName, resource));
            request.Method = httpMethod.ToUpper();
            request.ProtocolVersion = HttpVersion.Version11;
            request.ContentLength = 0;

            // TODO: change header value to configuration
            request.Headers.Add("x-ms-date", current.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
            request.Headers.Add("x-ms-version", "2011-08-18");
            request.ContentType = TableStorageRequestContentType;

            if (extraHeaders != null)
                foreach (var item in extraHeaders)
                {
                    request.Headers.Add(item.Key, item.Value);
                }

            byte[] bodyBytes = null;
            if (!string.IsNullOrWhiteSpace(requestBody))
            {
                bodyBytes = Encoding.UTF8.GetBytes(requestBody);
                request.ContentLength = bodyBytes.Length;
            }

            string auth = GenerateAuth(current, request);
            request.Headers.Add("Authorization", auth);

            // after writing, no more header adding
            if (!string.IsNullOrWhiteSpace(requestBody))
            {
                request.Headers.Add("Accept-Charset", "UTF-8");

                using (Stream newStream = request.GetRequestStream())
                {
                    newStream.Write(bodyBytes, 0, bodyBytes.Length);
                }
            }

            return request;
        }

        private string GenerateAuth(DateTime current, HttpWebRequest request)
        {
            /*
            byte[] bodyBytes = System.Text.UTF8Encoding.UTF8.GetBytes(body);
            MD5 md5 = MD5.Create();
            byte[] bodyMd5 = md5.ComputeHash(bodyBytes);

            StringBuilder sBuilder = new StringBuilder();

            // Loop through each byte of the hashed data 
            // and format each one as a hexadecimal string.
            for (int i = 0; i < bodyMd5.Length; i++)
            {
                sBuilder.Append(bodyMd5[i].ToString("x2"));
            }
            */
            string signature = request.Method.ToUpper() + "\n\n" +
                               TableStorageRequestContentType + "\n" +
                               current.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "\n" +
                               string.Format("/{0}{1}", _accountName, request.RequestUri.AbsolutePath);

            //string strToSign = string.Format(AuthToSignFormat, accountKey, accountName, signature);

            byte[] strBytes = System.Text.UTF8Encoding.UTF8.GetBytes(signature);
            HMACSHA256 hmac = new HMACSHA256(Convert.FromBase64String(_accountKey));
            return "SharedKey " + _accountName + ":" + Convert.ToBase64String(hmac.ComputeHash(strBytes));
        }
        #endregion
    }

    public class QueryEntitiesResult<T>
    {
        public List<T> Result { get; set; }
        public ContinuationQuery Continuation { get; set; }
    }
}
